#!/usr/bin/env python3
#
# vim: set expandtab shiftwidth=4 tabstop=4:
#
# Copyright 2016 Red Hat, Inc.
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the "Software"),
# to deal in the Software without restriction, including without limitation
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice (including the next
# paragraph) shall be included in all copies or substantial portions of the
# Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.

import evdev
import sys
import argparse
import re

from ratbagd import Ratbagd, RatbagdDevice, RatbagdProfile, RatbagdMacro, RatbagdResolution, RatbagdButton, RatbagdLed, RatbagdUnavailable, RatbagError, RatbagErrorCode, RatbagErrorCapability  # NOQA


button_specials_strmap = {
    **{e: e.name.lower().replace("_", "-") for e in RatbagdButton.ActionSpecial},
    **{e.name.lower().replace("_", "-"): e for e in RatbagdButton.ActionSpecial}
}


def list_devices(r, args):
    if not r.devices:
        print("No devices available.")

    for d in r.devices:
        print("{:20s} {:32s}".format(d.id + ":", d.name))


def find_device(r, args):
    dev = r[args.device]
    if dev is None:
        for d in r.devices:
            if args.device in d.name:
                return d
        print("Unable to find device {}".format(args.device))
        sys.exit(1)
    return dev


def find_profile(r, args):
    d = find_device(r, args)
    try:
        p = d.profiles[args.profile_n]
    except IndexError:
        print("Invalid profile index {}".format(args.profile_n))
        sys.exit(1)
    except AttributeError:
        p = d.active_profile
    return p, d


def find_resolution(r, args):
    p, d = find_profile(r, args)
    try:
        r = p.resolutions[args.resolution_n]
    except IndexError:
        print("Invalid resolution index {}".format(args.resolution_n))
        sys.exit(1)
    except AttributeError:
        r = p.active_resolution
    return r, p, d


def find_button(r, args):
    p, d = find_profile(r, args)
    try:
        b = p.buttons[args.button_n]
    except IndexError:
        print("Invalid button index {}".format(args.button_n))
        sys.exit(1)
    return b, p, d


def find_led(r, args):
    p, d = find_profile(r, args)
    try:
        l = p.leds[args.led_n]
    except IndexError:
        print("Invalid LED index {}".format(args.led_n))
        sys.exit(1)
    return l, p, d


def print_led(d, p, l, level):
    leds = {
        RatbagdLed.Mode.BREATHING: "breathing",
        RatbagdLed.Mode.CYCLE: "cycle",
        RatbagdLed.Mode.OFF: "off",
        RatbagdLed.Mode.ON: "on",
    }
    depths = {
        RatbagdLed.ColorDepth.MONOCHROME: "monochrome",
        RatbagdLed.ColorDepth.RGB_888: "rgb",
        RatbagdLed.ColorDepth.RGB_111: "rgb111",
    }
    if l.mode == RatbagdLed.Mode.OFF:
        print(" " * level + "LED: {}, depth: {}, mode: {}".format(l.index,
                                                                           depths[l.colordepth],
                                                                           leds[l.mode]))
    elif l.mode == RatbagdLed.Mode.ON:
        print(" " * level + "LED: {}, depth: {}, mode: {}, color: {:02x}{:02x}{:02x}".format(l.index,
                                                                                                      depths[l.colordepth],
                                                                                                      leds[l.mode],
                                                                                                      l.color[0],
                                                                                                      l.color[1],
                                                                                                      l.color[2]))
    elif l.mode == RatbagdLed.Mode.CYCLE:
        print(" " * level + "LED: {}, depth: {}, mode: {}, duration: {}, brightness: {}".format(l.index,
                                                                                                         depths[l.colordepth],
                                                                                                         leds[l.mode],
                                                                                                         l.effect_duration,
                                                                                                         l.brightness))
    elif l.mode == RatbagdLed.Mode.BREATHING:
        print(" " * level + "LED: {}, depth: {}, mode: {}, color: {:02x}{:02x}{:02x}, duration: {}, brightness: {}".format(l.index,
                                                                                                                                    depths[l.colordepth],
                                                                                                                                    leds[l.mode],
                                                                                                                                    l.color[0],
                                                                                                                                    l.color[1],
                                                                                                                                    l.color[2],
                                                                                                                                    l.effect_duration,
                                                                                                                                    l.brightness))


def print_led_caps(d, p, l, level):
    leds = {
        RatbagdLed.Mode.BREATHING: "breathing",
        RatbagdLed.Mode.CYCLE: "cycle",
        RatbagdLed.Mode.OFF: "off",
        RatbagdLed.Mode.ON: "on",
    }
    supported = sorted([v for k, v in leds.items() if k in l.modes])
    print(" " * level + "Modes: {}".format(", ".join(supported)))


def print_button(d, p, b, level):
    header = " " * level + "Button: {} is mapped to ".format(b.index)

    if b.action_type == RatbagdButton.ActionType.BUTTON:
        print("{}'button {}'".format(header, b.mapping))
    elif b.action_type == RatbagdButton.ActionType.SPECIAL:
        print("{}'{}'".format(header, button_specials_strmap[b.special]))
    elif b.action_type == RatbagdButton.ActionType.MACRO:
        print("{}macro '{}'".format(header, str(b.macro)))
    elif b.action_type == RatbagdButton.ActionType.NONE:
        print("{}none".format(header))
    else:
        print("{}UNKNOWN".format(header))


def print_resolution(d, p, r, level):
    if r.resolution == (0, 0):
        print(" " * level + "{}: <disabled>".format(r.index))
        return
    if len(r.resolution) == 2:
        dpi = "{}x{}".format(r.resolution[0], r.resolution[1])
    else:
        dpi = "{}".format(r.resolution[0])

    print(" " * level + "{}: {}dpi{}{}".format(r.index,
                                               dpi,
                                               " (active)" if r.is_active else "",
                                               " (default)" if r.is_default else "",
                                               ))


def print_profile(d, p, level):
    print(" " * (level - 2) + "Profile {}:{}{}".format(p.index,
                                                       " (disabled)" if not p.enabled else "",
                                                       " (active)" if p.is_active else ""))
    if p.enabled:
        print(" " * level + "Name: {}".format(p.name or 'n/a'))
        print(" " * level + "Report Rate: {}Hz".format(p.report_rate))
        print(" " * level + "Resolutions:")
        for r in p.resolutions:
            print_resolution(d, p, r, level + 2)
        for b in p.buttons:
            print_button(d, p, b, level)
        for l in p.leds:
            print_led(d, p, l, level)


def print_device(d, level):
    p = d.profiles[0]  # there should be always one

    print(" " * level + "{} - {}".format(d.id, d.name))
    print(" " * level + " Number of Buttons: {}".format(len(p.buttons)))
    print(" " * level + "    Number of Leds: {}".format(len(p.leds)))
    print(" " * level + "Number of Profiles: {}".format(len(d.profiles)))
    for p in d.profiles:
        print_profile(d, p, level + 2)


def show_device(r, args):
    d = find_device(r, args)
    print_device(d, 0)


def show_profile(r, args):
    p, d = find_profile(r, args)
    print("Profile {} on {} ({})".format(args.profile, d.id, d.name))
    print_profile(d, p, 0)


def show_resolution(r, args):
    r, p, d = find_resolution(r, args)
    print("Resolution {} on Profile {} on {} ({})".format(args.resolution,
                                                          args.profile,
                                                          d.id,
                                                          d.name))
    print_resolution(d, p, r, 0)
    caps = {RatbagdResolution.CAP_INDIVIDUAL_REPORT_RATE: "individual-report-rate",
            RatbagdResolution.CAP_SEPARATE_XY_RESOLUTION: "separate-xy-resolution"}
    capabilities = [caps[c] for c in r.capabilities]
    print("  Capabilities: {}".format(", ".join(capabilities)))


def show_button(r, args):
    b, p, d = find_button(r, args)
    print("Button {} on Profile {} on {} ({})".format(args.button,
                                                      args.profile,
                                                      d.id,
                                                      d.name))
    print_button(d, p, b, 0)


def func_led_get(r, args):
    l, p, d = find_led(r, args)
    print_led(d, p, l, 0)


def func_led_caps(r, args):
    l, p, d = find_led(r, args)
    print_led_caps(d, p, l, 0)


def func_led_set(r, args):
    l, p, d = find_led(r, args)
    try:
        mode = args.mode
    except AttributeError:
        pass
    else:
        leds = {
            "breathing": RatbagdLed.Mode.BREATHING,
            "cycle": RatbagdLed.Mode.CYCLE,
            "off": RatbagdLed.Mode.OFF,
            "on": RatbagdLed.Mode.ON,
        }
        l.mode = leds[mode]
    try:
        color = args.color
    except AttributeError:
        pass
    else:
        l.color = color
    try:
        duration = args.duration
    except AttributeError:
        pass
    else:
        l.effect_duration = duration
    try:
        brightness = args.brightness
    except AttributeError:
        pass
    else:
        l.brightness = brightness
    commit(d, args)


def func_led_get_all(r, args):
    p, d = find_profile(r, args)
    for l in p.leds:
        print_led(d, p, l, 0)


def func_button_get(r, args):
    b, p, d = find_button(r, args)
    print_button(b, p, b, 0)


def func_button_action_set_button(r, args):
    b, p, d = find_button(r, args)
    b.mapping = args.target_button
    commit(d, args)


def func_button_action_set_special(r, args):
    b, p, d = find_button(r, args)
    try:
        special = args.target_special
    except AttributeError:
        pass
    else:
        b.special = button_specials_strmap[special]
    commit(d, args)


def func_button_action_set_macro(r, args):
    b, p, d = find_button(r, args)
    if not b.ActionType.MACRO in b.action_types:
        raise RatbagErrorCapability("assigning a macro is not supported on this device")

    macro_keys = args.target_macro
    macro = RatbagdMacro()
    for s in macro_keys:
        is_press = True
        is_release = True
        is_timeout = False

        s = s.upper()
        if s[0] == 'T':
            is_timeout = True
            is_press = False
            is_release = False
        elif s[0] == '+':
            is_release = False
            s = s[1:]
        elif s[0] == '-':
            is_press = False
            s = s[1:]

        if is_timeout:
            t = int(s[1:])
            macro.append(RatbagdButton.Macro.WAIT, t)
        else:
            if not s.startswith("KEY_") and not s.startswith("BTN_"):
                msg = "Don't know how to convert {}".format(s)
                raise argparse.ArgumentTypeError(msg)

            code = evdev.ecodes.ecodes[s]
            if is_press:
                macro.append(RatbagdButton.Macro.KEY_PRESS, code)
            if is_release:
                macro.append(RatbagdButton.Macro.KEY_RELEASE, code)

    b.macro = macro
    commit(d, args)


def func_button_count(r, args):
    p, d = find_profile(r, args)
    print(len(p.buttons))


def func_dpi_get(r, args):
    r, p, d = find_resolution(r, args)
    if len(r.resolution) == 2:
        print("{}x{}dpi".format(r.resolution[0], r.resolution[1]))
    else:
        print("{}dpi".format(r.resolution[0]))


def func_dpi_get_all(r, args):
    r, p, d = find_resolution(r, args)
    dpis = r.resolutions
    print(" ".join([str(x) for x in dpis]))


def func_dpi_set(r, args):
    r, p, d = find_resolution(r, args)
    dpi = args.dpi_n
    if len(r.resolution) > len(dpi):
        dpi = (dpi[0], dpi[0])
    r.resolution = dpi
    commit(d, args)


def func_report_rate_get(r, args):
    p, d = find_profile(r, args)
    print(p.report_rate)


def func_report_rate_get_all(r, args):
    p, d = find_profile(r, args)
    rates = p.report_rates
    print(" ".join([str(x) for x in rates]))


def func_report_rate_set(r, args):
    p, d = find_profile(r, args)
    p.report_rate = args.rate_n
    commit(d, args)


def func_resolution_get(r, args):
    r, p, d = find_resolution(r, args)
    print_resolution(d, p, r, 0)


def func_resolution_active_get(r, args):
    p, d = find_profile(r, args)
    print(p.active_resolution.index)


def func_resolution_active_set(r, args):
    r, p, d = find_resolution(r, args)
    r.set_active()
    commit(d, args)


def func_resolution_default_get(r, args):
    p, d = find_profile(r, args)
    for r in p.resolutions:
        if r.is_default:
            break
    else:
        r = None
    print(r.index)


def func_default_resolution_set(r, args):
    # FIXME: capabilities check?
    r, p, d = find_resolution(r, args)
    r.set_default()
    commit(d, args)


def func_profile_get(r, args):
    p, d = find_profile(r, args)
    print_profile(d, p, 0)


def func_profile_name_get(r, args):
    p, d = find_profile(r, args)
    # See https://github.com/libratbag/libratbag/issues/617
    # ratbag converts to ascii, so this has no real effect there, but
    # ratbag-command may still have a non-ascii string.
    string = bytes(p.name, 'utf-8', 'ignore')
    print(string.decode('utf-8'))


def func_profile_name_set(r, args):
    p, d = find_profile(r, args)
    if not p.name:
        raise RatbagErrorCapability("assigning a profile name is not supported on this profile")
    p.name = args.name
    commit(d, args)


def func_profile_active_get(r, args):
    d = find_device(r, args)
    print(d.active_profile.index)


def func_profile_active_set(r, args):
    p, d = find_profile(r, args)
    p.set_active()
    commit(d, args)


def func_profile_enable(r, args):
    p, d = find_profile(r, args)
    p.enabled = True
    commit(d, args)


def func_profile_disable(r, args):
    p, d = find_profile(r, args)
    p.enabled = False
    commit(d, args)


def func_device_name_get(r, args):
    d = find_device(r, args)
    print(d.name)


################################################################################
# these are definitions to be reused in the dict that defines our language

# key elements
"""the type of the element (see 'types' below)"""
of_type = 'type'
"""the name of the element, it'll be the one matching the args on the CLI"""
name = 'name'
"""the group to logically associate commands while printing the help"""
group = 'group'
"""list of positional arguments for the given command"""
pos_args = 'pos_args'
"""a tag that we can refer latrer in an element of type 'link'"""
tag = 'tag'
"""the element pointed to in an element of type 'link'"""
dest = 'dest'
"""the function to associate to the switch or command"""
func = 'func'
"""this is a particular command that is an integer, but not an terminating argument.
example:
 profile active get
 profile **2** button 3 get
 - "profile" needs to be a switch
 - "2" needs to be translated as a N_access, given it is a requirement to be able to call 'button'
 """
N_access = 'N_access'

# argparse.add_argument parameters (forwarded as such)
"""'type' of the argument"""
arg_type = 'arg_type'
"""'metavar' of the argument"""
metavar = 'metavar'
"""'help' of the argument"""
help_str = 'help'
"""'nargs' of the argument"""
nargs = 'nargs'
"""'choices' of the argument"""
choices = 'choices'

# types
"""an option to interprete as a command (example 'list', 'info')"""
command = 'command'
"""an argument that is required for the given command arguments are leaf nodes
and can not have children
"""
argument = 'argument'
"""provides a list of choice of commands for instance, a switch of [A, B] means
we can have A or B only when parsing the command line
"""
switch = 'switch'
"""same as list, except we can loop inside the list for instance, a set of
[A, B] means we can have A and B (and A, ...) one after the other, no matter
the order
"""
set = 'set'
"""a reference to any other element in the tree marked with a tag"""
link = 'link'

################################################################################


def commit(device, args):
    if args.nocommit:
        return
    device.commit()


def color(string):
    try:
        int_value = int(string, 16)
    except ValueError:
        msg = "%r is not a color in hex format" % string
        raise argparse.ArgumentTypeError(msg)
    r = (int_value >> 16) & 0xff
    g = (int_value >> 8) & 0xff
    b = (int_value >> 0) & 0xff
    return (r, g, b)


def u8(string):
    int_value = int(string)
    msg = "%r is not a single byte" % string
    if int_value < 0 or int_value > 255:
        raise argparse.ArgumentTypeError(msg)
    return int_value


def dpi(string):
    try:
        int_value = int(string)
    except ValueError:
        pass
    else:
        return (int_value, )
    if string.endswith("dpi"):
        string = string[:-3]
    x, y = string.split("x")
    try:
        int_x = int(x)
        int_y = int(y)
    except ValueError:
        raise argparse.ArgumentTypeError("%r is not a valid dpi" % string)
    else:
        return (int_x, int_y)


# note: 'hidrawX' is assumed before each command
parser_def = [
    {
        of_type: command,
        name: 'info',
        help_str: 'Show device information',
        func: show_device,
        group: 'Device',
    },
    {
        of_type: command,
        name: 'name',
        help_str: 'Returns the device name',
        func: func_device_name_get,
    },
    {
        of_type: switch,
        name: 'profile',
        help_str: 'Access profile information',
        tag: 'profile',
        group: 'Profile',
        switch: [
            {
                of_type: switch,
                name: 'active',
                help_str: 'access active profile information',
                switch: [
                    {
                        of_type: command,
                        name: 'get',
                        help_str: 'Show current active profile',
                        func: func_profile_active_get,
                    },
                    {
                        of_type: command,
                        name: 'set',
                        help_str: 'Set current active profile',
                        pos_args: [
                            {
                                of_type: argument,
                                name: 'profile_n',
                                metavar: 'N',
                                help_str: 'The profile to set as current',
                                arg_type: int,
                            },
                        ],
                        func: func_profile_active_set,
                    },
                ],
            },
        ],
        N_access: {
            of_type: N_access,
            name: 'profile_n',
            metavar: 'N',
            help_str: 'The profile to act on',
            switch: [
                {
                    of_type: command,
                    name: 'get',
                    help_str: 'Show selected profile information',
                    func: func_profile_get,
                },
                {
                    of_type: switch,
                    name: 'name',
                    help_str: 'access profile name information',
                    switch: [
                        {
                            of_type: command,
                            name: 'get',
                            help_str: 'Show the name of the profile',
                            func: func_profile_name_get,
                        },
                        {
                            of_type: command,
                            name: 'set',
                            help_str: 'Set the name of the profile',
                            pos_args: [
                                {
                                    of_type: argument,
                                    name: 'name',
                                    metavar: 'blah',
                                    help_str: 'The name to set',
                                },
                            ],
                            func: func_profile_name_set,
                        },
                    ],
                },
                {
                    of_type: command,
                    name: 'enable',
                    help_str: 'Enable a profile',
                    func: func_profile_enable,
                },
                {
                    of_type: command,
                    name: 'disable',
                    help_str: 'Disable a profile',
                    func: func_profile_disable,
                },
                {
                    of_type: link,
                    dest: 'resolution',
                },
                {
                    of_type: link,
                    dest: 'dpi',
                },
                {
                    of_type: link,
                    dest: 'rate',
                },
                {
                    of_type: link,
                    dest: 'button',
                },
                {
                    of_type: link,
                    dest: 'led',
                },
            ],
        },
    },
    {
        of_type: switch,
        name: 'resolution',
        help_str: """Access resolution information

Resolution commands work on the given profile, or on the
active profile if none is given.""",
        tag: 'resolution',
        group: 'Resolution',
        switch: [
            {
                of_type: switch,
                name: 'active',
                help_str: 'access active resolution information',
                switch: [
                    {
                        of_type: command,
                        name: 'get',
                        help_str: 'Show current active resolution',
                        func: func_resolution_active_get,
                    },
                    {
                        of_type: command,
                        name: 'set',
                        help_str: 'Set current active resolution',
                        pos_args: [
                            {
                                of_type: argument,
                                name: 'resolution_n',
                                metavar: 'N',
                                help_str: 'The resolution to set as current',
                                arg_type: int,
                            },
                        ],
                        func: func_resolution_active_set,
                    },
                ],
            },
            {
                of_type: switch,
                name: 'default',
                help_str: 'access default resolution information',
                switch: [
                    {
                        of_type: command,
                        name: 'get',
                        help_str: 'Show current default resolution',
                        func: func_resolution_default_get,
                    },
                    {
                        of_type: command,
                        name: 'set',
                        help_str: 'Set current default resolution',
                        pos_args: [
                            {
                                of_type: argument,
                                name: 'resolution_n',
                                metavar: 'N',
                                help_str: 'The resolution to set as default',
                                arg_type: int,
                            },
                        ],
                        func: func_default_resolution_set,
                    },
                ],
            },
        ],
        N_access: {
            of_type: N_access,
            name: 'resolution_n',
            metavar: 'N',
            help_str: 'The resolution to act on',
            switch: [
                {
                    of_type: command,
                    name: 'get',
                    help_str: 'Show selected resolution',
                    func: func_resolution_get,
                },
                {
                    of_type: link,
                    dest: 'dpi',
                },
            ],
        },
    },
    {
        of_type: switch,
        name: 'dpi',
        help_str: """Access DPI information

DPI commands work on the given profile and resolution, or on the
active resolution of the active profile if none are given.""",
        tag: 'dpi',
        group: 'DPI',
        switch: [
            {
                of_type: command,
                name: 'get',
                help_str: 'Show current DPI value',
                func: func_dpi_get,
            },
            {
                of_type: command,
                name: 'get-all',
                help_str: 'Show all available DPIs',
                func: func_dpi_get_all,
            },
            {
                of_type: command,
                name: 'set',
                help_str: 'Set the DPI value to N',
                pos_args: [
                    {
                        of_type: argument,
                        name: 'dpi_n',
                        metavar: 'N',
                        help_str: 'The resolution to set as current',
                        arg_type: dpi,
                    },
                ],
                func: func_dpi_set,
            },
        ],
    },
    {
        of_type: switch,
        name: 'rate',
        help_str: """Access report rate information

Rate commands work on the given profile, or on the active profile if none is given.""",
        tag: 'rate',
        group: 'Rate',
        switch: [
            {
                of_type: command,
                name: 'get',
                help_str: 'Show current report rate',
                func: func_report_rate_get,
            },
            {
                of_type: command,
                name: 'get-all',
                help_str: 'Show all available report rates',
                func: func_report_rate_get_all,
            },
            {
                of_type: command,
                name: 'set',
                help_str: 'Set the report rate to N',
                pos_args: [
                    {
                        of_type: argument,
                        name: 'rate_n',
                        metavar: 'N',
                        help_str: 'The report rate to set as current',
                        arg_type: int,
                    },
                ],
                func: func_report_rate_set,
            },
        ],
    },
    {
        of_type: switch,
        name: 'button',
        help_str: """Access Button information

Button commands work on the given profile, or on the
active profile if none is given.""",
        tag: 'button',
        group: 'Button',
        switch: [
            {
                of_type: command,
                name: 'count',
                help_str: 'Print the number of buttons',
                func: func_button_count,
            },
        ],
        N_access: {
            of_type: N_access,
            name: 'button_n',
            metavar: 'N',
            help_str: 'The button to act on',
            switch: [
                {
                    of_type: command,
                    name: 'get',
                    help_str: 'Show selected button',
                    func: func_button_get,
                },
                {
                    of_type: switch,
                    name: 'action',
                    help_str: 'Act on the selected button',
                    switch: [
                        {
                            of_type: command,
                            name: 'get',
                            help_str: 'Print the button action',
                            func: func_button_get,
                        },
                        {
                            of_type: switch,
                            name: 'set',
                            help_str: 'Set an action on the selected button',
                            switch: [
                                {
                                    of_type: command,
                                    name: 'button',
                                    help_str: 'Set the button action to button B',
                                    pos_args: [
                                        {
                                            of_type: argument,
                                            name: 'target_button',
                                            metavar: 'B',
                                            help_str: 'The new button value to assign',
                                            arg_type: int,
                                        },
                                    ],
                                    func: func_button_action_set_button,
                                },
                                {
                                    of_type: command,
                                    name: 'special',
                                    help_str: 'Set the button action to special action S',
                                    pos_args: [
                                        {
                                            of_type: argument,
                                            name: 'target_special',
                                            metavar: 'S',
                                            help_str: 'The new special value to assign',
                                            choices: [
                                                "unknown",
                                                "doubleclick",
                                                "wheel-left",
                                                "wheel-right",
                                                "wheel-up",
                                                "wheel-down",
                                                "ratchet-mode-switch",
                                                "resolution-cycle-up",
                                                "resolution-cycle-down",
                                                "resolution-up",
                                                "resolution-down",
                                                "resolution-alternate",
                                                "resolution-default",
                                                "profile-cycle-up",
                                                "profile-cycle-down",
                                                "profile-up",
                                                "profile-down",
                                                "second-mode",
                                                "battery-level",
                                            ]
                                        },
                                    ],
                                    func: func_button_action_set_special,
                                },
                                {
                                    of_type: command,
                                    name: 'macro',
                                    help_str: """Set the button action to the given macro

  Macro syntax:
        A macro is a series of key events or waiting periods.
        Keys must be specified in linux/input.h key names.
        KEY_A                   Press and release 'a'
        +KEY_A                  Press 'a'
        -KEY_A                  Release 'a'
        t300                    Wait 300ms""",
                                    pos_args: [
                                        {
                                            of_type: argument,
                                            name: 'target_macro',
                                            metavar: '...',
                                            help_str: 'The new macro to assign',
                                            nargs: argparse.REMAINDER,
                                        },
                                    ],
                                    func: func_button_action_set_macro,
                                },
                            ]
                        },
                    ]
                },
            ],
        },
    },
    {
        of_type: switch,
        name: 'led',
        help_str: """Access LED information

LED commands work on the given profile, or on the
active profile if none is given.""",
        tag: 'led',
        group: 'LED',
        switch: [
            {
                of_type: command,
                name: 'get',
                help_str: 'Show current LED value',
                func: func_led_get_all,
            },
        ],
        N_access: {
            of_type: N_access,
            name: 'led_n',
            metavar: 'N',
            help_str: 'The LED to act on',
            switch: [
                {
                    of_type: command,
                    name: 'get',
                    help_str: 'Show current LED value',
                    func: func_led_get,
                },
                {
                    of_type: command,
                    name: 'capabilities',
                    help_str: 'Show LED capabilities',
                    func: func_led_caps,
                },
                {
                    of_type: set,
                    name: 'set',
                    help_str: 'Act on the selected LED',
                    switch: [
                        {
                            of_type: command,
                            name: 'mode',
                            help_str: 'The mode to set as current',
                            pos_args: [
                                {
                                    of_type: argument,
                                    name: 'mode',
                                    metavar: 'mode',
                                    help_str: 'The mode to set as current',
                                    choices: ['on', 'off', 'cycle', 'breathing'],
                                },
                            ],
                        },
                        {
                            of_type: command,
                            name: 'color',
                            help_str: 'The color to set as current',
                            pos_args: [
                                {
                                    of_type: argument,
                                    name: 'color',
                                    metavar: 'RRGGBB',
                                    help_str: 'The color in hex format to set as current',
                                    arg_type: color,
                                },
                            ],
                        },
                        {
                            of_type: command,
                            name: 'duration',
                            help_str: 'The duration to set as current',
                            pos_args: [
                                {
                                    of_type: argument,
                                    name: 'duration',
                                    metavar: 'R',
                                    help_str: 'The duration in ms to set as current',
                                    arg_type: int,
                                },
                            ],
                        },
                        {
                            of_type: command,
                            name: 'brightness',
                            help_str: 'The brightness to set as current',
                            pos_args: [
                                {
                                    of_type: argument,
                                    name: 'brightness',
                                    metavar: 'B',
                                    help_str: 'The brightness to set as current',
                                    arg_type: u8,
                                },
                            ],
                        },
                    ],
                    func: func_led_set,
                },
            ],
        },
    },
]


class ParseError(Exception):
    pass


class RatbagParser(object):
    tagged = {}

    def __init__(self, type, name, group=None, tag=None, func=None, help=None):
        self.type = type
        self.name = name
        self.tag = tag
        self.group = group
        if tag is not None:
            RatbagParser.tagged[tag] = self
        self.func = func
        self.help = help

    def repr_args(self):
        return "name='{}', tag='{}', func='{}', help='{}'".format(self.name, self.tag, self.func, self.help)

    def __repr__(self):
        return "{}({})".format(type(self), self.repr_args())

    def store_function(self, parser):
        if self.func is not None:
            parser.set_defaults(func=self.func)

    def _add_to_subparsers(self, parent, input_string, ns):
        raise ParseError("please implement _add_to_subparsers on {}".format(type(self)))

    def add_to_subparsers(self, parent):
        self._add_to_subparsers(parent)

    def _sub_parse(self, input_string, ns):
        raise ParseError("please implement _sub_parse on {}".format(type(self)))

    def sub_parse(self, input_string, ns):
        r = self._sub_parse(input_string, ns)
        return r

    def build_cmd_args_name(self):
        """uniquely tag the arguments of the command in the namespace"""
        return "{}_args_{}".format(self.name)

    def print_help(self, group, prefix=""):
        if self.group is not None:
            print("\n{} Commands:".format(self.group))
        self._print_help(prefix)

    def _print_help(self, prefix):
        raise ParseError("please implement _print_help on {}".format(type(self)))


class RatbagParserSwitch(RatbagParser):
    def __init__(self, type, name, group=None, switch=[], N_access=None, tag=None, func=None, help=None):
        super(RatbagParserSwitch, self).__init__(type, name, group, tag, func, help)
        self.switch = [classes[obj[of_type]](**obj) for obj in switch]
        if N_access is not None:
            self.N_access = RatbagParserNAccess(**N_access)
        else:
            self.N_access = None

    def repr_args(self):
        return """switch='{}', N_access='{}', {}""".format([repr(o) for o in self.switch], self.N_access, RatbagParser.repr_args(self))

    def _add_to_subparsers(self, parent):
        parser = parent.add_parser(self.name, help=self.help)
        parser.set_defaults(subparse=self.sub_parse)

    def _sub_parse(self, input_string, ns):
        if input_string and self.N_access is not None:
            # retrieve first numbered element if any
            try:
                int(input_string[0])
            except ValueError:
                # there are arguments, but they look like commands
                pass
            else:
                # we have a single int as first argument, switch to the
                # N_access subtree of the command
                return self.N_access._sub_parse(self, input_string, ns)

        parser = argparse.ArgumentParser(prog="{} <device> {}".format(sys.argv[0], self.name),
                                         description=self.help,
                                         add_help=False)

        # create a new subparser to handle all commands
        subs = parser.add_subparsers(title="COMMANDS", help=None)
        for e in self.switch:
            e.add_to_subparsers(subs)

        return parser

    def _print_help(self, prefix):
        if self.help and self.group is not None:
            string = self.help.split('\n')
            string = "\n  ".join(string)
            print(" ", string, '\n')
        for e in self.switch:
            e.print_help(None, "{}{} ".format(prefix, self.name))
        if self.N_access is not None:
            self.N_access.print_help(None, self.name + " ")

    def __repr__(self):
        return "switch({})".format(self.repr_args())


class RatbagParserNAccess(RatbagParserSwitch):
    def __init__(self, type, name, group=None, switch=[], metavar=None, tag=None, func=None, help=None):
        super(RatbagParserNAccess, self).__init__(type, name, group, switch, None, tag, func, help)
        self.metavar = metavar

    def _sub_parse(self, parent, input_string, ns):
        parser = argparse.ArgumentParser(prog="{} <device> {}".format(sys.argv[0], parent.name),
                                         add_help=False)
        parser.add_argument(self.name, help=self.help, type=int)
        # create a new subparser to handle all commands
        subs = parser.add_subparsers(title="COMMANDS", help=None)
        for e in self.switch:
            e.add_to_subparsers(subs)
        return parser

    def repr_args(self):
        return """switch='{}', metavar = '{}', {}""".format([repr(o) for o in self.switch], self.metavar, RatbagParser.repr_args(self))

    def __repr__(self):
        return "N_Access({})".format(self.repr_args())

    def _print_help(self, prefix):
        for e in self.switch:
            e.print_help(None, "{}N ".format(prefix))


class RatbagParserSet(RatbagParserSwitch):
    def __init__(self, type, name, group=None, switch=[], N_access=None, tag=None, func=None, help=None):
        super(RatbagParserSet, self).__init__(type, name, group, switch, N_access, tag, func, help)

    def _add_to_subparsers(self, parent):
        parser = parent.add_parser(self.name, help=self.help)
        parser.set_defaults(subparse=self.sub_parse)

    def _sub_parse(self, input_string, ns):
        parser = argparse.ArgumentParser(prog="{} <device> {}".format(sys.argv[0], self.name),
                                         add_help=False)
        # create a new subparser to handle all commands
        subs = parser.add_subparsers(title="COMMANDS", help=None)
        for e in self.switch:
            e.add_to_subparsers(subs)
        if len(input_string) == 2:
            self.store_function(parser)
        else:
            parser.set_defaults(subparse=self.sub_parse)
        return parser

    def __repr__(self):
        return "set({})".format(self.repr_args())

    def _print_help(self, prefix):
        command = prefix + "{COMMAND} ..."
        print("  {:<36}{}".format(command,
                                  self.help if self.help else ""))
        for e in self.switch:
            e.print_help(None, " " * len(prefix))


class RatbagParserCommand(RatbagParser):
    def __init__(self, type, name, group=None, pos_args=[], tag=None, func=None, help=None):
        super(RatbagParserCommand, self).__init__(type, name, group, tag, func, help)
        self.pos_args = [classes[obj[of_type]](**obj) for obj in pos_args]

    def _add_to_subparsers(self, parent):
        parser = parent.add_parser(self.name, help=self.help)
        for a in self.pos_args:
            a.add_to_subparsers(parser)
        self.store_function(parser)

    def __repr__(self):
        return "command({})".format(self.repr_args())

    def _print_help(self, prefix):
        command = prefix + self.name
        for a in self.pos_args:
            if a.choices is None or len(a.choices) > 5:
                command += " {}".format(a.metavar)
            else:
                command += " [{}]".format("|".join(a.choices))
        print("  {:<36}{}".format(command,
                                  self.help if self.help else ""))


class RatbagParserArgument(RatbagParser):
    def __init__(self, type, name, group=None, arg_type=None, metavar=None, nargs=None, choices=None, tag=None, func=None, help=None):
        super(RatbagParserArgument, self).__init__(type, name, group, tag, func, help)
        self.arg_type = arg_type
        self.metavar = metavar
        self.nargs = nargs
        self.choices = choices

    def _add_to_subparsers(self, parent):
        parent.add_argument(self.name, metavar=self.metavar, help=self.help, type=self.arg_type, nargs=self.nargs, choices=self.choices)


class RatbagParserLink(RatbagParser):
    def __init__(self, type, dest=None, group=None, tag=None, func=None, help=None):
        super(RatbagParserLink, self).__init__(type, dest, group, tag, func, help)
        self.dest = dest

    def get_dest(self):
        try:
            dest = RatbagParser.tagged[self.dest]
        except KeyError:
            raise ParseError("link '{}' points to nothing".format(self.dest))
        return dest

    def _add_to_subparsers(self, parent):
        dest = self.get_dest()
        dest.add_to_subparsers(parent)

    def _print_help(self, prefix):
        dest = self.get_dest()
        print("  {:<36}Use {}for '{} Commands'".format(prefix + self.dest + " ...", prefix, dest.group))


classes = {
    switch: RatbagParserSwitch,
    set: RatbagParserSet,
    command: RatbagParserCommand,
    argument: RatbagParserArgument,
    link: RatbagParserLink,
    N_access: RatbagParserNAccess,
}


class RatbagParserRoot(object):
    def __init__(self, commands):
        self.children = [classes[def_parser[of_type]](**def_parser) for def_parser in commands]
        self.want_keepalive = False

    def parse(self, input_string):
        self.parser = argparse.ArgumentParser(description="Inspect and modify a configurable device",
                                              add_help=False)
        self.parser.add_argument("-V", "--version", action="version", version="@version@")
        self.parser.add_argument('--verbose', '-v', action='count', default=0)
        self.parser.add_argument('--help', '-h', action='store_true', default=False)
        self.parser.add_argument('--nocommit', action='store_true', default=False)
        if self.want_keepalive:
            self.parser.add_argument('--keepalive', action='store_true', default=False)

        # retrieve the global options now and remove them from the processing
        ns, rest = self.parser.parse_known_args(input_string)

        if ns.help:
            return ns

        # retrieve the device and remove it from the command processing
        self.parser.add_argument('device_or_list', action="store")
        ns, rest = self.parser.parse_known_args(rest, namespace=ns)

        if ns.device_or_list == 'list':
            if rest:
                self.parser.error("extra arguments: '{}'".format(" ".join(rest)))
            ns.func = list_devices
            return ns

        ns.device = ns.device_or_list

        # we need a new parser or 'device_or_list' will eat all of our commands
        command_parser = argparse.ArgumentParser(description="command parser",
                                                 prog="{} <device>".format(sys.argv[0]),
                                                 add_help=False)

        subs = command_parser.add_subparsers(title="COMMANDS")

        subparser = command_parser

        for child in self.children:
            child.add_to_subparsers(subs)

        ns.subparse = None

        while rest and subparser:
            old_rest = rest
            ns, rest = subparser.parse_known_args(rest, namespace=ns)
            if hasattr(ns, func):
                break
            if old_rest == rest:
                break
            if ns.subparse:
                subparser = ns.subparse(rest, ns)

        if rest:
            self.parser.error("extra arguments: '{}'".format(" ".join(rest)))

        return ns

    def print_help(self):
        print("usage: {} [OPTIONS] list".format(self.parser.prog))
        print("       {} [OPTIONS] <device> {{COMMAND}} ...\n".format(self.parser.prog))
        print(self.parser.description)
        print("""
Common options:
    --version -V                show program's version number and exit
    --verbose, -v               increase verbosity level
    --nocommit                  Do not immediately write the settings to the mouse
    --help, -h                  show this help and exit""")
        if self.want_keepalive:
            print("    --keepalive                 do not terminate ratbagd after the processing")
        print("""
General Commands:
  list                                List supported devices (does not take a device argument)""")
        for c in self.children:
            c.print_help(None)
        print("""
Examples:
  {0} profile active get
  {0} profile 0 resolution active set 4
  {0} profile 0 resolution 1 dpi get
  {0} resolution 4 rate get
  {0} dpi set 800
  {0} profile 0 led 0 set mode on
  {0} profile 0 led 0 set color ff00ff
  {0} profile 0 led 0 set duration 50

Exit codes:
  0     Success
  1     Unsupported feature, index out of available range or invalid device
  2     Commandline arguments are invalid
  3     A command failed on the device
""".format(self.parser.prog))


def get_parser():
    return RatbagParserRoot(parser_def)


def on_device_added(ratbagd, device):
    device_names = [
            'mara', 'capybara', 'porcupine', 'paca',
            'vole', 'woodrat', 'gerbil', 'shrew',
            'hutia', 'beaver', 'squirrel', 'chinchilla',
            'rabbit', 'viscacha', 'hare', 'degu',
            'gundi', 'acouchy', 'nutria', 'paca',
            'hamster', 'zokor', 'chipmunk', 'gopher',
            'marmot', 'groundhog', 'suslik', 'agouti',
            'blesmol',
    ]

    device_attr = [
            'sobbing', 'whooping', 'barking', 'yapping',
            'howling', 'squawking', 'cheering', 'warbling',
            'thundering', 'booming', 'blustering', 'humming',
            'crying', 'bawling', 'roaring', 'raging',
            'chanting', 'crooning', 'murmuring', 'bellowing',
            'wailing', 'weeping', 'screaming', 'yelling',
            'yodeling', 'singing', 'honking', 'hooting',
            'whispering', 'hollering',
    ]

    # Let's convert the sha into something not boring. This takes the first
    # 4 characters, creates two different indices from it to generate a
    # name. The rest is hope hope that never get a collision here but it's
    # unlikely enough.
    name = device_names[int(device.id[0:2], 16) % len(device_names)]
    attr = device_attr[int(device.id[2:4], 16) % len(device_attr)]
    device.id = "-".join([attr, name])

def open_ratbagd(ratbagd_process=None, verbose=0):
    try:
        r = Ratbagd()
        r.verbose = verbose
        try:
            r.connect('device-added', on_device_added)
            for d in r.devices:
                on_device_added(r, d)
        except AttributeError:
            pass  # the ratbag-command case

    except RatbagdUnavailable as e:
        print("Unable to connect to ratbagd: {}".format(e))
        return None

    if ratbagd_process is not None:
        # if some old version of ratbagd is still running, ratbagd_process may
        # have never started but our DBus bindings may succeed. Check for the
        # return code here, this also gives ratbagd enough time to start and
        # die. If we check immediately we may not have terminated yet.
        ratbagd_process.poll()
        assert ratbagd_process.returncode is None

    return r


def main(argv):
    if not argv:
        argv = ["list"]

    parser = get_parser()
    cmd = parser.parse(argv)
    if cmd.help:
        parser.print_help()
        return

    _r = open_ratbagd(verbose=cmd.verbose)
    if _r is not None:
        with _r as r:
            try:
                f = cmd.func
            except AttributeError:
                parser.print_help()
                return
            else:
                try:
                    f(r, cmd)
                except RatbagErrorCapability as e:
                    print("Error: {}".format(e), file=sys.stderr)


if __name__ == "__main__":
    main(sys.argv[1:])
